React SSR Note(1)
SSR已经不是什么新词、新概念了。为了解决React、Vue这类单页面(SPA)带来的首屏过慢以及SEO等问题随之而出。Vue、React官方也一直再跟进相关的支持。 同时社区也诞生了Next.js(React)以及Nuxt(vue)这类已经配置好,开箱即用的SSR解决方案。
之前在研究学习Webpack的时候,折腾过React SSR的配置和使用。从零配置Webpack4.0搭建一个React工程并且在蛮多自己瞎搞的项目中使用过,这几周自己再折腾SSR与Redux的结合。这一全面总结一下,就当温故知新了。
什么是服务端渲染/为什么使用服务端渲染
服务端渲染顾名思义浏览器通过url向服务器发送请求,服务器处理好页面的内容将整个页面返还给浏览器。每当请求一个url,服务器要将该url对应的页面内容发送给浏览器。
而SPA单页面应用则是在一开始一股脑将整个bundle.js加载进来。具体流程如下:
浏览器发送请求 -> 服务器返回HTML(空的骨架) -> 浏览器发送bundle.js -> 服务器返回bundle.js ->浏览器执行React/Vue代码
之后所有的url变成操作都通过这个js来处理,不需要再重新加载整个页面。web应用更具响应性,用户体验更好。但是由于一开始要加载一大坨bundle.js就会带来首屏过慢的问题。由于整个网页的内容都在js中,自然而然html源码什么都没有,不利于SEO。
为了解决痛点SSR就来了。它不是传统意义的服务端渲染,只是首屏渲染,首屏通过服务器直接生成,之后的由客户端(浏览器)接管。这样既能解决首屏速度、SEO还能同事拥有单页面应用带给用户的流畅性。(这里有一个专有名词叫做:同构--一套代码在服务器执行一遍,在客户端执行一遍) 具体流程如下:
浏览器发送请求 -> 服务器运行React代码生成页面 -> 服务器返回页面 -> 浏览器加载bundle.js -> bundle.js的React代码在浏览器重新执行 -> bundle.js的React重新接管页面操作。
这里主要说说React中的SSR核心由于虚拟DOM的存在(真实DOM的JS对象映射) 在SPA中
ReactDom.render(<Home />, id/root)
在SSR中虚拟dom的存在让SSR变得很简单
renderToString(<Home />)
把虚拟dom转化为字符串,返给服务器就是了
Simple Demo
首先可以在分别创建server文件夹及client文件夹。我们这里简单的写写一套相同内容的代码 server/index.js
import express from 'express'
import React from 'react'
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom'
import Routes from '../routes'
const app = express() // 通过express搭建一个服务器
app.use(express.static('public')) // 只要请求静态文件就在public这个文件夹中找
app.get('/', function(req, res) {
const content = renderToString( // 在server使用react-router-dom需要用StaticRouter
<StaticRouter location={ req.path } context={{}} >
{ Routes }
</StaticRouter>)
// 通过 <script src='/index.js' ></script> 在调用client
res.send(`
<html>
<head>
<title>ssr</title>
</head>
<body>
<div id='root' >${ content }</div>
<script src='/index.js' ></script>
</body>
</html>
`)
})
var server = app.listen(3000)
client/index.js
import React from 'react'
import ReactDom from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import Routes from '../routes'
const App = () => {
return (
<BrowserRouter>
{ Routes }
</BrowserRouter>
)
}
ReactDom.hydrate(<App />, document.getElementById('root'))
client端就是我们熟悉的React代码这里就不在废话
sever、client共用了一个路由配置 routes.js
import React from 'react'
import { Route } from 'react-router-dom'
import Home from './containers/Home' //一个简单的Component
export default (
<div>
<Route path='/' exact component={ Home } ></Route>
</div>
)
这里着重说一下webpack配置.这里拆成个三个文件。webpack.base.js 、webpack.client.js、webpack.server.js
// webpack.base.js
module.exports = {
module: {
rules: [{
test: /\.js?$/,
loader: 'babel-loader',
exclude: /node_modules/,
options: {
presets: ['react', 'stage-0',['env', {
targets: {
browsers: ['last 2 versions']
}
}]]
}
}]
}
}
// webpack.client.js
const path = require('path')
const merge = require('webpack-merge')
const config = require('./webpack.base')
const clientConfig = {
mode: 'development',
entry: './src/client/index.js',
output: {
filename: 'index.js',
path: path.resolve(__dirname, 'public')
}
}
module.exports = merge(config, clientConfig)
// webpack.server.js
const path = require('path')
const nodeExternals = require('webpack-node-externals') // 排除 node_modules 目录中所有模块
const merge = require('webpack-merge')
const config = require('./webpack.base')
const serverConfig = {
target: 'node',
mode: 'development',
entry: './src/server/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'build')
},
externals: [nodeExternals()]
}
module.exports = merge(config, serverConfig)
这样一个最简单的SSR Demo就搞定了
SSR与Redux、React-Router
SSR同构配合Redux使用还蛮复杂的,中间涉及到数据的注水和脱水。首先我们面临的第一个问题是server端怎么根据路由,往store里面加数据。 这里我们通过数组对象的方式设置Route。完整Project: https://github.com/laclys/rengar
比如:
export default [{
path: '/',
component: App,
loadData: App.loadData,
key: 'app',
routes: [{
path: '/',
component: Home,
exact: true,
loadData: Home.loadData,
key: 'home'
},
{
path: '/translation',
component: Translation,
exact: true,
loadData: Translation.loadData,
key: 'translation'
},
{
component: NotFound,
}
]
}]
这么一个路由。用过loadData进行server端对应数据的拉取(server端没有生命周期)。比如在Home这个组件里我们定义了一个loadData静态方法
Home.loadData = (store) => {
// 负责在服务器端渲染之前,把这个路由需要的数据提前加载好
return store.dispatch(getHomeList())
}
顺便提一下客户端拉取数据当然
componentDidMount() {
if (!this.props.list.length) { // 这层判断为了避免server端已经拉取数据了,没必要重复拉取。至于怎么从server渲染的页面拿到数据后面再说
this.props.getList()
}
}
有了这些准备就可以在server端生成首屏的html。这里import { matchRoutes } from 'react-router-config'使用matchRoutes来遍历routes
app.get('*', function (req, res) {
const store = getStore(req)
// 根据路由的路径,往store里面加数据
const matchedRoutes = matchRoutes(routes, req.path)
const promises = []
matchedRoutes.forEach(item => { // 匹配含有loadData的路由,执行并赋值到store中
if (item.route.loadData) {
const promise = new Promise((resolve, reject) => {
item.route.loadData(store).then(resolve).catch(resolve)
})
promises.push(promise) // 不管数据加载成功还是失败,把能准备的数据都准备好。更好的容错不必担心那个接口请求失败。都会执行promise.all
}
})
Promise.all(promises).then(() => {
const context = {}
const html = render(store, routes, req, context) // 生成html。
if (context.action === 'REPLACE') {
res.redirect(301, context.url)
} else if (context.NOT_FOUND) { // 处理404页面
res.status(404)
res.send(html)
} else {
res.send(html)
}
})
})
渲染html的render函数
export const render = (store, routes, req, context) => {
const content = renderToString(
<Provider store={ store } >
<StaticRouter location={ req.path } context={ context } >
<div>
{ renderRoutes(routes) }
</div>
</StaticRouter>
</Provider>)
return `
<html>
<head>
<title>ssr</title>
</head>
<body>
<div id='root' >${ content }</div>
<script>
window.context = {
state: ${ JSON.stringify(store.getState()) }
}
</script>
<script src='/index.js' ></script>
</body>
</html>
`
}
其中
<script>
window.context = {
state: ${ JSON.stringify(store.getState()) }
}
</script>
通过这种方式将server端已经获取的store数据进行注入。到时客户端js接管之后直接从context中拿
client/index:
const store = getClientStore()
const App = () => {
return (
<Provider store={ store } >
<BrowserRouter>
<div>
{ renderRoutes(routes) }
</div>
</BrowserRouter>
</Provider>
)
}
ReactDom.hydrate(<App />, document.getElementById('root'))
getClientStore
export const getClientStore = () => {
const defaultState = window.context.state // 客户端getstore时,拿server已经获取过得数据。这块叫做脱水
return createStore(reducer, defaultState, applyMiddleware(thunk.withExtraArgument(clientAxios)))
}
thunk.withExtraArgument是redux-thunk中一个可以传入一个值。在使用redux-thunk时候调用
顺道提一下服务器端获取请求cookie这块。可以通过axios的create方法拿到
const createInstance = (req) => axios.create({
baseURL: 'http://47.95.113.63/ssr/',
headers: {
cookie: req.get('cookie') || ''
}
})
export default createInstance
以上! 捣鼓之后SSR确实是一个有意思的东西。很多东西不能说是坑,只能说是自己还没了解到。后续还会再看看CSS样式在server端中的处理和更好的SEO处理。